winbrew_app\operations\doctor/
report.rs1use anyhow::Result;
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12use crate::AppContext;
13use crate::database;
14
15use super::scan;
16use crate::models::domains::installed::InstalledPackage;
17use crate::models::domains::reporting::{
18 DiagnosisResult, DiagnosisSeverity, HealthReport, RecoveryFinding,
19};
20use crate::models::reporting::HealthScanTimings;
21
22fn display_path(path: impl AsRef<Path>) -> String {
27 path.as_ref().to_string_lossy().into_owned()
28}
29
30fn sort_report_diagnostics(left: &DiagnosisResult, right: &DiagnosisResult) -> std::cmp::Ordering {
37 left.severity
38 .cmp(&right.severity)
39 .then_with(|| left.error_code.cmp(&right.error_code))
40 .then_with(|| left.description.cmp(&right.description))
41}
42
43fn collect_packages(
49 packages_result: Result<Vec<InstalledPackage>>,
50) -> (Vec<InstalledPackage>, Vec<DiagnosisResult>) {
51 match packages_result {
52 Ok(packages) => (packages, Vec::new()),
53 Err(err) => (
54 Vec::new(),
55 vec![DiagnosisResult {
56 error_code: "installed_packages_unavailable".to_string(),
57 description: format!("installed packages: unavailable ({err})"),
58 severity: DiagnosisSeverity::Error,
59 }],
60 ),
61 }
62}
63
64fn collect_initial_recovery_findings(diagnostics: &[DiagnosisResult]) -> Vec<RecoveryFinding> {
66 diagnostics
67 .iter()
68 .filter_map(RecoveryFinding::from_diagnosis)
69 .collect()
70}
71
72fn measure<T>(operation: impl FnOnce() -> T) -> (T, Duration) {
73 let started_at = Instant::now();
74 let value = operation();
75 (value, started_at.elapsed())
76}
77
78fn sort_recovery_findings(left: &RecoveryFinding, right: &RecoveryFinding) -> std::cmp::Ordering {
79 left.action_group
80 .cmp(&right.action_group)
81 .then_with(|| left.severity.cmp(&right.severity))
82 .then_with(|| left.error_code.cmp(&right.error_code))
83 .then_with(|| left.target_path.cmp(&right.target_path))
84 .then_with(|| left.description.cmp(&right.description))
85}
86
87pub fn health_report(ctx: &AppContext) -> Result<HealthReport> {
95 let paths = &ctx.paths;
96 let started_at = Instant::now();
97 let (conn_result, database_connection_duration) = measure(database::get_conn);
98 let conn = conn_result?;
99
100 let (packages_result, installed_packages_duration) =
101 measure(|| scan::installed_packages(&conn));
102 let (packages, mut diagnostics) = collect_packages(packages_result);
103 let mut recovery_findings = collect_initial_recovery_findings(&diagnostics);
104 let (orphan_scan, orphan_scan_duration) =
105 measure(|| scan::scan_orphaned_install_dirs(&paths.packages, &packages));
106 let (package_scan, package_scan_duration) = measure(|| scan::scan_packages(&packages));
107 let scan::PackageInstallScan {
108 diagnostics: package_diagnostics,
109 recovery_findings: package_recovery_findings,
110 } = package_scan;
111 let (msi_scan, msi_scan_duration) = measure(|| scan::scan_msi_inventory(&conn, &packages));
112 let scan::MsiInventoryScan {
113 diagnostics: msi_diagnostics,
114 recovery_findings: msi_recovery_findings,
115 } = msi_scan;
116 let (journal_scan, journal_scan_duration) =
117 measure(|| scan::scan_package_journals(paths, &packages));
118 let scan::PackageJournalScan {
119 diagnostics: journal_diagnostics,
120 recovery_findings: journal_recovery_findings,
121 } = journal_scan;
122
123 diagnostics.extend(package_diagnostics);
124 diagnostics.extend(msi_diagnostics);
125 diagnostics.extend(orphan_scan.diagnostics);
126 diagnostics.extend(journal_diagnostics);
127 diagnostics.sort_unstable_by(sort_report_diagnostics);
129 recovery_findings.extend(package_recovery_findings);
130 recovery_findings.extend(msi_recovery_findings);
131 recovery_findings.extend(orphan_scan.recovery_findings);
132 recovery_findings.extend(journal_recovery_findings);
133 recovery_findings.sort_unstable_by(sort_recovery_findings);
134
135 let error_count = diagnostics
136 .iter()
137 .filter(|diagnosis| matches!(diagnosis.severity, DiagnosisSeverity::Error))
138 .count();
139
140 Ok(HealthReport {
141 database_path: display_path(&paths.db),
142 database_exists: paths.db.exists(),
143 catalog_database_path: display_path(&paths.catalog_db),
144 catalog_database_exists: paths.catalog_db.exists(),
145 install_root_source: if ctx.root_from_env {
146 "env override".to_string()
147 } else {
148 "config:paths.root".to_string()
149 },
150 install_root: display_path(&paths.root),
151 install_root_exists: paths.root.exists(),
152 packages_dir: display_path(&paths.packages),
153 diagnostics,
154 recovery_findings,
155 scan_timings: HealthScanTimings {
156 database_connection: database_connection_duration,
157 installed_packages: installed_packages_duration,
158 package_scan: package_scan_duration,
159 msi_scan: msi_scan_duration,
160 orphan_scan: orphan_scan_duration,
161 journal_scan: journal_scan_duration,
162 },
163 scan_duration: started_at.elapsed(),
164 error_count,
165 })
166}
167
168#[cfg(test)]
169mod tests {
170 use super::{collect_initial_recovery_findings, collect_packages, sort_report_diagnostics};
171 use crate::models::domains::reporting::{
172 DiagnosisResult, DiagnosisSeverity, RecoveryActionGroup, RecoveryIssueKind,
173 };
174 use anyhow::anyhow;
175
176 #[test]
177 fn collect_packages_converts_errors_into_diagnostics() {
178 let (packages, diagnostics) = collect_packages(Err(anyhow!("database unavailable")));
179
180 assert!(packages.is_empty());
181 assert_eq!(diagnostics.len(), 1);
182 assert_eq!(diagnostics[0].error_code, "installed_packages_unavailable");
183 assert_eq!(diagnostics[0].severity, DiagnosisSeverity::Error);
184 assert!(diagnostics[0].description.contains("database unavailable"));
185 }
186
187 #[test]
188 fn sort_report_diagnostics_keeps_errors_before_warnings() {
189 let mut diagnostics = [
190 DiagnosisResult {
191 error_code: "warning_b".to_string(),
192 description: "warning".to_string(),
193 severity: DiagnosisSeverity::Warning,
194 },
195 DiagnosisResult {
196 error_code: "error_a".to_string(),
197 description: "error".to_string(),
198 severity: DiagnosisSeverity::Error,
199 },
200 DiagnosisResult {
201 error_code: "error_c".to_string(),
202 description: "error".to_string(),
203 severity: DiagnosisSeverity::Error,
204 },
205 ];
206
207 diagnostics.sort_unstable_by(sort_report_diagnostics);
208
209 assert_eq!(diagnostics[0].severity, DiagnosisSeverity::Error);
210 assert_eq!(diagnostics[1].severity, DiagnosisSeverity::Error);
211 assert_eq!(diagnostics[2].severity, DiagnosisSeverity::Warning);
212 }
213
214 #[test]
215 fn collect_packages_keeps_empty_package_lists_empty() {
216 let (packages, diagnostics) = collect_packages(Ok(Vec::new()));
217
218 assert!(packages.is_empty());
219 assert!(diagnostics.is_empty());
220 }
221
222 #[test]
223 fn collect_initial_recovery_findings_maps_policy_issues() {
224 let diagnostics = vec![DiagnosisResult {
225 error_code: "stale_package_journal".to_string(),
226 description: "Contoso.App: recovery journal does not match installed package"
227 .to_string(),
228 severity: DiagnosisSeverity::Error,
229 }];
230
231 let findings = collect_initial_recovery_findings(&diagnostics);
232
233 assert_eq!(findings.len(), 1);
234 assert_eq!(findings[0].issue_kind, RecoveryIssueKind::Conflict);
235 assert_eq!(
236 findings[0].action_group,
237 Some(RecoveryActionGroup::JournalReplay)
238 );
239 assert_eq!(findings[0].severity, DiagnosisSeverity::Error);
240 }
241}